Repository navigation
Agents: a companion per agent, <agent>.prebuilt.restate, and the agent's own contexts - #311
Conversation
…state Whatever makes an agent (a framework's agent(), AgentObject, with_companion) now also makes its companion: a virtual object of its own, named `<agent>.agentprebuild.restate` (COMPANION_SUFFIX) and keyed as the agent (`alice`, not `assistant:alice`). The agent serves it alongside itself; the shared `signals` object is gone, and nothing is served or reserved under that name. The companion knows its agent statically, and is ingress-private as a whole: clients reach it through the agent's views. make_companion(agent) builds it, with the same handlers, names and payloads as before; Companion holds the object and typed references to its handlers. Callers address it with ctx.call(companion.<handler>, ..., key=ctx.key): views through their agent, a turn's code through companion_in(ctx), others through companion_of(agent), which replaces `signals` in restate.agents.internal. Shims for a companion of another SDK or an older deployment are removed: Started.settles (every dead turn is sent to settle), the turn's own publish when start returns nothing, and Inbox.saw taking a publish without a revision. Started.steers and Started.publishes stay: a framework that cannot take steered messages, and a background agent, are real cases. Upgrading leaves the old `signals` object's state behind: turns in flight, pending waits and the revision do not carry over (docs/agents/turns.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
`answer_once` no longer publishes after each commit of a turn's steps: `chat_turn` records what follows a commit (`after_steps`, a per-attempt scope value, set only for a followed turn), and `answer_once` hands it to `Conversation.hold` as an opaque callback, after forgetting the calls whose results the commit stored (`settled`, which no longer rides on a publish). So a plain session object's handler that prompts stores its turn and makes no publish nor companion lookup. `publish` from a context with no companion now raises (`companion_in`) instead of silently skipping. Waiting for a signal (`wait_for_signal`, `wait_for`, LangGraph's interrupts and `nodes.wait_for_signal`) from a plain session object fails with a terminal error that says to use an agent, instead of a TypeError that Restate retried forever. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
The conversation stores; the agent's chat turn publishes; a handler of your own that prompts stores its turn and publishes nothing; waits and steering need an agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
…ommit - Every handler of an `AgentObject` (not only `chat`) has the steps its turns store publish, through `publish_steps`, as before 8f7ad14: a handler you add that prompts publishes each committed step again (no start nor close: only `chat` is a turn in the companion). A background agent's publish nothing, as before. - `publish` no longer forgets settled calls: `answer_once` does it once per commit, before what follows it; the `settle` handler publishes outside any turn, where it did nothing. - Tests: LangGraph's `nodes.wait_for_signal` and `interrupt()` from a plain session object fail the turn terminally with the needs-agent message. - resurrect.py: re-point `pydantic-denial-approves` at today's `approval_of`; it is caught by three tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
…in TypeScript `needs_agent` now raises `NeedsAgent`, a `TerminalError` that `Tool.run` (and Pydantic AI's tool error hook) never turns into the tool's result for the model: every wait from a session object that is no agent, the signal tool included, fails the turn once, for good, closed `failed`. A tool's other terminal errors still go to the model. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
A turn that runs in a handler other than `chat`, on an agent object, published after each commit of its steps but never at the end: a follower saw the steps arrive and then heard nothing, because only `chat_turn` calls the companion's `close`, which is what wakes followers as a `chat` turn ends. It now publishes once more, after the commit that stores the turn's answer, never before it: a follower told earlier would read a conversation that has no answer yet. A turn closed for good (failed, refused, stopped) publishes its end too, since its close stores the turn's answer; a turn whose attempt failed publishes none, as it has not ended and the next attempt carries it on. A turn nobody follows (a background agent's) publishes neither its steps nor its end, as before. The seam is `after_answer`, beside `after_steps`: every handler of an `AgentObject` sets it (`publish_end`, guarded by the same `_unfollowed` check as `publish_steps`), and `answer_once` runs it as an opaque callback once the answer is stored. A `chat` turn sets nothing there: its end is the companion's `close`, which publishes its last change with it, so its publishes are what they were (`start`, one per commit of its steps, `close`) and it gains no round trip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
…s `signals` Both SDKs ship one `signals` object for a whole endpoint, keyed `<agent>:<key>`. A cluster has one service of that name, so a Python endpoint and a TypeScript endpoint registered against it take the name from each other: one SDK's companion then serves the other's agents. The design note records the move to one object per agent, named `<agent>.prebuilt.restate` and keyed as the agent: why the shared name cannot stay, why the marker is a suffix, that only agents have one, what the rename touches in both repos, how a deployment on `signals` migrates (drain first; the revision restarts per key), and what must hold afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
The shared `signals` and `<agent>.agentprebuilt.restate` are both preview, and neither per-agent companion has been released: the new object starts empty for every key, and what a running `signals` holds is dropped with it. The conversation is untouched either way, since the companion holds none of it. So the note drops the upgrade path, with the alias and the revision tolerance it asked for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
An agent's companion is already its own object, keyed as the agent: the name is what is left to settle. `agent` in the suffix says nothing the agent's own name does not, so the marker is `.prebuilt.restate`, and an agent's companion is as unique as the agent itself. Restate refuses names that start with `restate`, so it stays a suffix. The design note keeps the old spelling where it describes what came before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
The script still counted a service named `signals` as a companion, for the object every agent shared before. Nothing serves it any more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
An agent's APIs and a session object's take the same context today, so a handler reaching up a layer — a signal wait from a plain session object — is only found out while the turn runs. The note names the two contexts, as protocols in Python with a marker that never exists at runtime and as plain `extends` in TypeScript: a signpost in the signature, with `NeedsAgent` left as the guard that actually stops the wait. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
An agent's handler and a session object's take the same context, so a handler that reaches up a layer is only found out while the turn runs. The two protocols extend the session contexts with a private marker that is declared and never present, so a plain session context is not one of them for a type checker, and the layer is in the signature where the person writing the handler reads it. Nothing of them exists at runtime: the session tier goes on building its attempt contexts, nothing reads the marker, and `NeedsAgent` stays the guard that stops a wait from a session object. What takes them is the agent's own surface: `chat`'s `answer` and the bound it is registered under, the built-in views, the private handlers, and what only an agent does with a context, which reaches its companion (`chat_turn`, `publish` and the publishes around a turn, `cleared`). The tier below keeps `SessionContext`, and that is wider than the design note's table says. A plain session object drives the conversation: the tests register `answer_once` on one, and each framework's `prompt` is documented for a handler of your own and takes a session context, so `answer_once`, `settle` and `compact`, the turn's `settings_of` and `stop_signal_of`, `take_steers`, `wait_for` and `companion_in` are all reached from there and stay as they are. The langgraph signal node and the interrupt resume read their context from the run's, and guard it with `waits_in`, which raises `NeedsAgent`: narrowing them would need a cast that says the opposite of what the guard is for. Two tests pass a plain context to `publish` on purpose, to show it still raises; they now say so with a cast. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
The note's first draft asked for the conversation, the turn's helpers, the signal waits and the langgraph nodes to take an agent's context. None of them can: a handler of a plain session object drives `answer_once`, each framework's public `prompt` funnels into it, and `companion_at` and `waits_in` are the probes that find out which tier they are on. Narrowing them is a type error in the SDK's own tests. What narrowed is the agent's registration surface, which is the honest reach of a name that is only a signpost. The note now says so, and says why TypeScript, which is barred from nothing, holds the same line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
There was a problem hiding this comment.
ℹ️ No critical issues. Two rough edges are flagged inline.
Reviewed changes
This review covers the full PR at e3ede15: the per-agent companion, the split between storing and publishing, and the new context protocols. The in-process suites agents_companion, agents_conversation and agents_publishes pass locally (81 passed).
- A companion for each agent:
make_companion(agent)builds an ingress-private<agent>.prebuilt.restatevirtual object keyed as the agent, and it replaces the sharedsignals.AgentObject.__restate_services__serves it beside the agent, andStarted.settlesis gone because onlychat_turnstarts turns. - The conversation only stores:
answer_oncenow runs whatever its caller set throughafter_steps/after_answer. TheAgentObjecthandler wrapper sets both.chat_turnkeeps the per-step publishes but clears the end publish, becauseclosealready publishes the end. A plainSessionObjectneither publishes nor looks up a companion. NeedsAgent: a signal wait from a plain session object now fails the turn for good.Tool.runand Pydantic AI'son_tool_execute_errorlet it propagate instead of returning it to the model as a tool result.AgentContext/AgentViewContext: these protocols mark the agent layer in signatures using a phantom_agentmember. They now typechat()'sTypeVarbound, the built-in views, the private handlers and the turn's publish helpers.- API changes in
_signalsandinternal:companion_of(agent)no longer takes a key and returns the handlers.companion_at,companion_inandwaits_inreplacesignals_of.signalsis removed frominternal.__all__.
claude-opus-5-5 | 𝕏
An agent's own handler sets a publish for the commit that stores its turn's answer, and `answer_once` read it only after the check for an answer already stored. An attempt that died between that commit and the publish therefore lost the end: the retry found the turn answered, returned, and nobody told the followers, which heard nothing until their watch timed out. A `chat` turn was never affected, since its `close` publishes the end on the retry too. The hook is read before the answer, and a retry that finds one runs it. Publishing an end twice costs a revision and tells a follower what it may already know. The companion's own name stays unguarded, which the note now says rather than requiring a refusal that nothing implements: an agent named `support.prebuilt.restate` takes agent `support`'s companion name across endpoints, and `Endpoint.bind` catches it only within one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
There was a problem hiding this comment.
✅ No new issues found. Both earlier findings are addressed.
Reviewed changes
This review covers f6d4d49, the one commit pushed since the last Pullfrog review. I re-ran the in-process agents_publishes, agents_conversation, agents_companion and agents_object suites: 82 passed. The 12 errors were Docker-harness tests, and there is no Docker in this environment. I also reproduced the retry directly. When ask is invoked again with the same invocation id, the turn's answer is already stored. The handler returns "done" and publishes exactly once, and that publish already shows the answer.
- A retry that finds the turn answered now publishes its end:
answer_oncereadsafter_answer_of(ctx)before theanswer_inearly return and awaits it there. An attempt that dies between committing the answer and publishing it no longer leaves followers waiting.chatis unaffected, becausechat_turnclears the hook. Thepublish_enddocstring now describes this path. docs/agents/prebuilt.mdno longer describes a name guard: the doc now says that an agent named<x>.prebuilt.restateis deliberately not refused.Endpoint.bindcatches the clash only within one endpoint. The "what must hold" list no longer includes the refusal.
claude-opus-5-5 | 𝕏
This branch gave each agent a companion of its own, `<agent>.prebuilt.restate`, keyed as the agent: its calls go through typed handler references, with the agent's own key, and the shared `signals` object and its `<agent>:<key>` key are gone. Main timed model requests and tool calls in the journal, and hid the agent's bookkeeping from Restate's UI: every call and send to the companion is named `_signals.ENTRY_NAME`, and the agent's metadata hides the entries of that name (`hide_entries`). Both are kept. The companion's calls stay typed and keyed as the agent, and each carries main's journal name: `ctx.call(companion.reset, key=ctx.key, name=_signals.ENTRY_NAME)`. Main's `_companion` helper is not kept: it existed to cast away the types of a call addressed by name, which the typed handlers made unnecessary, so each view passes `name=` at its call site instead. The name's value is still `signals`, a stale label for an object no longer called that; renaming it is a change of its own. The turn's publishes are this branch's (a start that publishes, a publish per committed step, a close), and the test that main added for the names asks the agent for its companion rather than for the shared object. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV
There was a problem hiding this comment.
✅ No new issues found. The merge of
maincarries #310's entry naming over to the per-agent companion completely.
Reviewed changes
This review covers 86f7556, the merge of upstream main (with #310) into the branch. I checked that the merge resolved the agents code correctly. The in-process agents_publishes, agents_conversation, agents_companion, agents_object and sessions_object suites pass (111 passed). The 12 errors are Docker-harness tests, which cannot run in this environment. ruff check is clean.
- Named the companion calls: every agent call to the companion that
mainnamessignalsis named on the branch too. That covers the views (signal,pending,status,notifications,watch),_stopped,steer, the reset inerase,sync's watch and reads, the turn'sstart/publish/close, and thetakensend. Tool waits stay unnamed, as they are onmain. - Merged the hiding metadata:
AgentObjectputshide_entries(_signals.ENTRY_NAME)under any metadata the user passes, so an agent's own metadata still wins. - Adapted the naming test: the new
test_the_calls_to_the_companion_are_named_for_the_agents_metadata_to_hide_themnow expects<agent>.prebuilt.restate/...handlers, notsignals/....
claude-opus-5-5 | 𝕏

Three pieces of work on
restate.agents, in the order they landed. The TypeScript side is restatedev/sdk-sessions#24; the two SDKs must agree on the companion's name before either releases.Each agent brings its own companion
signalswas one virtual object for the whole endpoint, keyed<agent>:<key>. There is one service of that name in a Restate cluster, so a Python endpoint and a TypeScript endpoint registered against the same cluster take the name from each other: whichever registered last serves the companion for both SDKs' agents, with its own handler set and payload details, and nothing warns about it. Serving one agent per image has the same problem inside one language, since every image also servessignals.Now each agent brings its own,
<agent>.prebuilt.restate, keyed as the agent and served beside it through__restate_services__. A companion is as unique as its agent, and an agent's name is already unique in a cluster.agentin the suffix said nothing the agent's own name does not, so the marker is.prebuilt.restate; Restate refuses names that start withrestate, so it stays a suffix.The companion holds what it always held: the tools' waits, the message being answered, the steering inbox, and the conversation's revision. Its handler names and payloads are unchanged, including the camelCase
awaitTurnthat is wire-shared with the TypeScript SDK.No migration. The shared
signalsand the per-agent companion are both preview, and the state does not move: the new object starts empty for every key. The conversation itself is untouched — it lives in the key's SQL database, and the companion holds none of it.docs/agents/prebuilt.mdrecords the decision and why the shared name could not stay. The companion's name is not guarded: an agent namedsupport.prebuilt.restatewould own agentsupport's companion name, andEndpoint.bindcatches that only within one endpoint. The note says so.The chat turn publishes; the conversation stores
Publishing belongs to the agent's chat turn rather than to
answer_once: an agent's own handlers publish their steps and the turn's end, with one settle per commit. A retry that finds the turn already answered publishes its end too, so an attempt that died between the commit storing the answer and the publish does not leave followers waiting for awatchtimeout. A signal wait from a handler of a plain session object now fails for good withNeedsAgent, as it does in TypeScript, rather than calling a service nobody serves and being retried until the turn hangs.docs/agents/turns.mdsays which layer stores and which publishes.AgentContextandAgentViewContextThe agents' API and a session object's took the same context, so a handler reaching up a layer was only found out while the turn ran, after a model round-trip, looking like a tool failure.
The two protocols name the layer in the signature. They are a signpost, not a fence: the marker member is declared and never exists at runtime, nothing reads it,
restate/sessions/does not change, andNeedsAgentstays the guard that actually stops the wait. They are applied to the agent's registration surface —chat()'sanswer, the built-in views, the private handlers (erase,settle,compact,steered,clear,sync,messages,steer), and the turn's publish helpers.chat's TypeVar bound moves with it, which is the one strict signature on the public surface. Everything else is additive: parameter types are contravariant, so a handler annotatedSessionContextstill registers on an agent and still type-checks, and no existing user code breaks.The conversation stays on
SessionContext, deliberately. A handler of a plain session object drivesanswer_once—tests/agents_conversation.pyregisters it on asessions.SessionObject, and each framework's publicprompt(ctx: SessionContext, ...), the documented "for a handler of your own", funnels into it. The same holds forwait_for,take_steers,settings_of,companion_atand the LangGraph signal node, which are reached from a framework'sprompt()or are the probes that find out which tier they are on.docs/agents/contexts.mdsays what narrowed and what could not, and why.The tools (
tool,call,send,workflow,subagent) andToolCall.ctxstay onSessionContexttoo: a tool is a value a framework may run from either tier, so the type cannot reach through it.Merging main's #310
maingained journal timing and the hiding of an agent's bookkeeping calls (ENTRY_NAME,hide_entries) while this branch was open, written against the sharedsignalsand its<agent>:<key>addressing. The merge keeps this branch's per-agent companion calls and carries #310's entry name onto them, so every callmainnames is named here: the views,_stopped,steer,erase's reset,sync's reads, the turn'sstart/publish/close, and thetakensend.main's naming test is kept whole, with its expected handlers re-pointed fromsignals/...to<agent>.prebuilt.restate/....Two things left as
mainhas them, worth a decision separately:ENTRY_NAMEis still the literal"signals", now a stale label for an object of another name that deployed agents' metadata already carries; and the companion'swaitcalls are the one kind that stays unnamed, so a tool's wait is visible in the UI while every other bookkeeping call is hidden.Verified
ruff checkclean,pyright python/0 errors,pyright tests0 errors,mypyclean. The agents and sessions suites run 560 passed, 0 failed.The server-backed tests ran against a local
restate-serverbinary rather than the Docker harness, since this environment has no Docker daemon; CI runs them the usual way.Nothing under
restate/sessions/changed:git diff main...HEAD -- python/restate/sessions/is empty.🤖 Generated with Claude Code
https://claude.ai/code/session_01RePHkSS8KRKmJ93hcd9asV